浏览器与Node的事件循环(Event Loop)

Event Loop 即事件循环,是指浏览器或 Node 的一种解决 javaScript 单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。

进程与线程

上面说了 js 单线程执行的,那什么是线程和进程?

进程是 CPU 资源分配的最小单位;线程是 CPU 调度的最小单位。一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线,并且一个进程的内存空间是共享的,每个线程都可用这些共享内存。

进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间

把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程、定时触发器线程、事件触发线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间。

事件循环 Event Loop

在 JavaScript 中,任务被分为两种,一种宏任务(MacroTask)也叫Task,一种叫微任务(MicroTask)。

MacroTask(宏任务)

script 全部代码、setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN)、I/O、UI Rendering。

MicroTask(微任务)

Process.nextTick(Node独有)、Promise、Object.observe(废弃)、MutationObserver(具体使用方式查看 这里

浏览器中的 Event Loop

Javascript 有一个 main thread 主线程和 call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。

JS调用栈

JS调用栈采用的是后进先出的规则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。

同步任务和异步任务

Javascript单线程任务被分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后(例如 setTimeout 的等待时间结束),将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。

任务队列 Task Queue,即队列,先进先出,即异步任务先进来先执行。

任务执行步骤

首先要明白:

  • JS分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件
  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行

具体执行步骤(运行机制):

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
    渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

案例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
console.log('script start');

setTimeout(function() {
console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');

/*
script start
script end
promise1
promise2
setTimeout
*/

这题比较简单,就不解析了

案例二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//请写出输出内容
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}

console.log('script start');

setTimeout(function() {
console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');


/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

解析:

  1. 首先,事件循环从宏任务 (macrotask) 队列开始,这个时候,宏任务队列中,只有一个 script(整体代码)任务;当遇到任务源 (task source) 时,则会先分发任务到对应的任务队列中去。

  2. 然后我们看到首先定义了两个 async 函数,接着往下看,然后遇到了 console 语句,直接输出 script start。输出之后,script 任务继续往下执行,遇到 setTimeout,其作为一个宏任务源,则会先将其任务分发到对应的队列中:

  3. script 任务继续往下执行,执行了async1()函数,前面讲过 async 函数中在 await 之前的代码是立即执行的,所以会立即输出 async1 start。

遇到了 await 时,会将 await 后面的表达式执行一遍,所以就紧接着输出 async2,然后将 await 后面的代码也就是 console.log(‘async1 end’) 加入到 microtask 中的 Promise 队列中,接着跳出async1函数来执行后面的代码。

  1. script任务继续往下执行,遇到 Promise 实例。由于Promise中的函数是立即执行的,而后续的 .then 则会被分发到 microtask 的 Promise 队列中去。所以会先输出 promise1,然后执行 resolve,将 promise2 分配到对应队列。

  2. script任务继续往下执行,最后只有一句输出了 script end,至此,全局任务就执行完毕了。

根据上述,每次执行完一个宏任务之后,会去检查是否存在 Microtasks;如果有,则执行 Microtasks 直至清空 Microtask Queue。

  1. 因而在 script 任务执行完毕之后,开始查找清空微任务队列。此时,微任务中, Promise 队列有的两个任务 async1 end 和 promise2,因此按先后顺序输出 async1 end,promise2。当所有的 Microtasks 执行完毕之后,表示第一轮的循环就结束了。

  2. 第二轮循环依旧从宏任务队列开始。此时宏任务中只有一个 setTimeout,取出直接输出即可,至此整个流程结束。

案例三:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const first = () => (new Promise((resolve,reject)=>{
console.log(3);
let p = new Promise((resolve, reject)=>{
console.log(7);
setTimeout(()=>{
console.log(5);
resolve(6);
},0)
resolve(1);
});
resolve(2);
p.then((arg)=>{
console.log(arg);
});

}));

first().then((arg)=>{
console.log(arg);
});
console.log(4);

简单解析:

第一轮事件循环:

先执行宏任务,主script ,new Promise 立即执行,输出【3】,执行p这个 new Promise 操作,输出【7】,发现 setTimeout,将回调放入下一轮任务队列(Event Queue),p 的 then,姑且叫做then1,放入微任务队列,发现 first 的 then,叫 then2,放入微任务队列。执行 console.log(4),输出【4】,宏任务执行结束。

再执行微任务,执行 then1,输出【1】,执行 then2,输出【2】。到此为止,第一轮事件循环结束。开始执行第二轮。

第二轮事件循环:

先执行宏任务里面的,也就是 setTimeout 的回调,输出【5】。resovle 不会生效,因为 p 这个 Promise 的状态一旦改变就不会在改变了。 所以最终的输出顺序是:3、7、4、1、2、5。

Node 中的 Event Loop

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。

node

Node.js 采用 V8 作为 js 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API(时间,非阻塞的网络,异步文件操作,子进程等等),Event Loop 也是它里面的实现。

Node.js的运行机制如下:

  • V8 引擎解析 JavaScript 脚本。
  • 解析后的代码,调用 Node API。
  • libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。
  • V8 引擎再将结果返回给用户。

node

Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

timer

timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。

同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行

I/O

I/O 阶段会处理一些上一轮循环中的少数未执行的 I/O 回调

idle, prepare

idle, prepare 阶段内部实现,仅node内部使用,这里就不详细讲了。

poll

poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情

  • 回到 timer 阶段执行回调
  • 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情:

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
  • 如果 poll 队列为空时,会有两件事发生:
    • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
    • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

check

check 阶段执行 setImmediate() 的回调

close callbacks

close callbacks 阶段执行 socket 的 close 事件

在以上的内容中,我们了解了 Node 中的 Event Loop 的执行顺序,接下来我们将会通过代码的方式来深入理解这块内容。

首先在有些情况下,定时器的执行顺序其实是随机的:

1
2
3
4
5
6
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})

首先你需要知道:

  • setTimeout 和 setImmediate 二者非常相似,区别主要在于调用时机不同。
  • setImmediate 设计在poll阶段完成时执行,即check阶段;
  • setTimeout 设计在poll阶段为空闲时,且设定时间到达后执行,但它在timer阶段执行

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后:

  • setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了

当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:

1
2
3
4
5
6
7
8
9
10
const fs = require('fs')

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

上面介绍的都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列:

1
2
3
4
5
6
7
setTimeout(() => {
console.log('timer21')
}, 0)

Promise.resolve().then(function() {
console.log('promise1')
})

对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。

process.nextTick

最后我们来讲讲 Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)

process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

对于以上代码,大家可以发现无论如何,永远都是先把 nextTick 全部打印出来。

node 中的宏任务(MacroTask)和微任务(MicroTask)

Node端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列:

  • 常见的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作等

  • 常见的 micro-task 比如: process.nextTick、new Promise().then(回调)等

案例

案例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2
  1. 一开始执行栈的同步任务(这属于宏任务)执行完毕后(依次打印出 start end,并将 2 个 timer 依次放入 timer 队列),会先去执行微任务(这点跟浏览器端的一样),所以打印出 promise3

  2. 然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将promise.then回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;这点跟浏览器端相差比较大,timers 阶段有几个 setTimeout/setInterval 都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务。

案例二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}

async function async2() {
new Promise(function(resolve) {
console.log('promise1');
resolve();
console.log("promiseResolve")
}).then(function() {
setTimeout(function() {
console.log('setTimeout1');
})
console.log('promise2');
});
}

console.log('script start');

setTimeout(function() {
console.log('setTimeout2');
}, 0)

async1();

process.nextTick(() => {
console.log("nextTick");
})

new Promise(function(resolve) {
console.log('promise3');
resolve();
setTimeout(() => {
console.log('setTimeout3')
})
}).then(function() {
console.log('promise4');
})
.then(() => console.log('promise5'))
.then(() => console.log('promise6'))

console.log('script end');

/*
script start
async1 start
promise1
promiseResolve
promise3
nextTick // nextTick有自己的队列,优先于其它微任务先执行
promise2
script end
async1 end
promise4
promise5
promise6
setTimeout2
setTimeout3
setTimeout1
*/

这题和平时浏览器执行的顺序一样,多了一个 nextTick

参考文章:

前端面试之道